1"use client";
2
3
16import { useParams } from "next/navigation";
17import Link from "next/link";
18import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
19import { Bookmark, BookmarkX, Database as DatabaseIcon, FileText, Loader2 } from "lucide-react";
20import { formatRelativeTime } from "@/lib/utils";
21import { pageApi } from "@/lib/api";
22import { useWorkspace } from "@/hooks/use-workspace";
23import { Page } from "@/lib/types";
24
25export default function FavoritesPage() {
26 const params = useParams<{ workspace: string }>();
27 const slugOrId = params?.workspace ?? "";
28 const workspaceSlug = params?.workspace ?? "default";
29 const { data: workspace } = useWorkspace(slugOrId);
30 const qc = useQueryClient();
31
32 const favorites = useQuery({
33
34
35 queryKey: ["workspaces", workspace?.id ?? slugOrId, "favorites"],
36 queryFn: () => pageApi.list(workspace?.id ?? slugOrId, { favorites: true, limit: 200 }),
37 enabled: !!workspace?.id,
38 retry: false
39 });
40
41 const unfavorite = useMutation({
42 mutationFn: (pageId: string) => pageApi.removeFavorite(pageId),
43 onSuccess: (_data, pageId) => {
44 qc.invalidateQueries({ queryKey: ["favorites"] });
45 qc.invalidateQueries({ queryKey: ["workspaces", workspace?.id, "favorites"] });
46 qc.invalidateQueries({ queryKey: ["pages", pageId] });
47 }
48 });
49
50 const items = favorites.data ?? [];
51
52 return (
53 <div className="mx-auto max-w-2xl px-6 py-8">
54 <h1 className="mb-1 flex items-center gap-2 text-xl font-semibold">
55 <Bookmark className="size-5 text-muted-foreground" aria-hidden /> Favorites
56 </h1>
57 <p className="mb-4 text-sm text-muted-foreground">
58 Pages you've starred. Star one from a page's toolbar or its context menu.
59 </p>
60
61 {favorites.isLoading && (
62 <p className="inline-flex items-center gap-2 text-sm text-muted-foreground">
63 <Loader2 className="size-4 animate-spin" /> Loading favorites…
64 </p>
65 )}
66
67 {favorites.isError && (
68 <p className="rounded-md border border-dashed border-border p-4 text-sm text-muted-foreground">
69 Couldn't load your favorites.
70 </p>
71 )}
72
73 {!favorites.isLoading && !favorites.isError && items.length === 0 && (
74 <p className="rounded-md border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
75 No favorites yet. Open a page and click the star in its toolbar (or right-click a
76 page in the sidebar → “Add to favorites”).
77 </p>
78 )}
79
80 <ul className="space-y-1.5">
81 {items.map((p: Page) => (
82 <li
83 key={p.id}
84 data-testid={`favorite-item-${p.id}`}
85 className="flex items-center gap-2.5 rounded-md border border-border px-3 py-2"
86 >
87 <Link
88 href={`/w/${workspaceSlug}/page/${p.id}`}
89 className="flex min-w-0 flex-1 items-center gap-2.5"
90 >
91 <span className="shrink-0 text-sm" aria-hidden>
92 {p.icon || (p.databaseId ? (
93 <DatabaseIcon className="size-4 text-blue-600" />
94 ) : (
95 <FileText className="size-4 text-muted-foreground" />
96 ))}
97 </span>
98 <span className="truncate text-sm">{p.title || "Untitled"}</span>
99 </Link>
100 {p.trashedAt && (
101 <span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
102 In trash
103 </span>
104 )}
105 <span className="shrink-0 text-[11px] text-muted-foreground">
106 Updated {formatRelativeTime(p.updatedAt)}
107 </span>
108 <button
109 type="button"
110 data-testid={`favorite-remove-${p.id}`}
111 className="inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs text-muted-foreground hover:bg-muted disabled:opacity-50"
112 onClick={() => unfavorite.mutate(p.id)}
113 disabled={unfavorite.isPending}
114 aria-label={`Remove “${p.title || "Untitled"}” from favorites`}
115 >
116 <BookmarkX className="size-3.5" aria-hidden /> Remove
117 </button>
118 </li>
119 ))}
120 </ul>
121 </div>
122 );
123}
124